在 Android 开发中使用协程 | 上手指南
保持对协程的追踪
本系列文章的第一篇,我们探讨了协程适合用来解决哪些问题。这里再简单回顾一下,协程适合解决以下两个常见的编程问题:
处理耗时任务 (Long running tasks),这种任务常常会阻塞住主线程; 保证主线程安全 (Main-safety),即确保安全地从主线程调用任何 suspend 函数。
协程通过在常规函数之上增加 suspend 和 resume 两个操作来解决上述问题。当某个特定的线程上的所有协程被 suspend 后,该线程便可腾出资源去处理其他任务。
协程自身并不能够追踪正在处理的任务,但是有成百上千个协程并对它们同时执行挂起操作并没有太大问题。协程是轻量级的,但处理的任务却不一定是轻量的,比如读取文件或者发送网络请求。
使用代码来手动追踪上千个协程是非常困难的,您可以尝试对所有协程进行跟踪,手动确保它们都完成了或者都被取消了,那么代码会臃肿且易出错。如果代码不是很完美,就会失去对协程的追踪,也就是所谓 "work leak" 的情况。
任务泄漏 (work leak) 是指某个协程丢失无法追踪,它类似于内存泄漏,但比它更加糟糕,这样丢失的协程可以恢复自己,从而占用内存、CPU、磁盘资源,甚至会发起一个网络请求,而这也意味着它所占用的这些资源都无法得到重用。
在 Android 平台上,我们可以使用结构化并发来做到以下三件事:
取消任务 —— 当某项任务不再需要时取消它; 追踪任务 —— 当任务正在执行时,追踪它; 发出错误信号 —— 当协程失败时,发出错误信号表明有错误发生。
结构化并发
https://kotlinlang.org/docs/reference/coroutines/basics.html#structured-concurrency
借助 scope 来取消任务
启动新的协程
launch 构建器适合执行 "一劳永逸" 的工作,意思就是说它可以启动新协程而不将结果返回给调用方;
async 构建器可启动新协程并允许您使用一个名为 await 的挂起函数返回 result。
scope.launch {
// 这段代码在作用域里启动了一个新协程
// 它可以调用挂起函数
fetchDocs()
}
注意: launch 和 async 之间的很大差异是它们对异常的处理方式不同。async 期望最终是通过调用 await 来获取结果 (或者异常),所以默认情况下它不会抛出异常。这意味着如果使用 async 启动新的协程,它会静默地将异常丢弃。
launch https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html async https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html
在 ViewModel 中启动协程
既然 CoroutineScope 会追踪由它启动的所有协程,而 launch 会创建一个新的协程,那么您应该在什么地方调用 launch 并将其放在 scope 中呢? 又该在什么时候取消在 scope 中启动的所有协程呢?
在 Android 平台上,您可以将 CoroutineScope 实现与用户界面相关联。这样可让您避免泄漏内存或者对不再与用户相关的 Activities 或 Fragments 执行额外的工作。当用户通过导航离开某界面时,与该界面相关的 CoroutineScope 可以取消掉所有不需要的任务。
结构化并发能够保证当某个作用域被取消后,它内部所创建的所有协程也都被取消。
当将协程同 Android 架构组件 (Android Architecture Components) 集成起来时,您往往会需要在 ViewModel 中启动协程。因为大部分的任务都是在这里开始进行处理的,所以在这个地方启动是一个很合理的做法,您也不用担心旋转屏幕方向会终止您所创建的协程。
从生命周期感知型组件 (AndroidX Lifecycle) 的 2.1.0 版本开始 (发布于 2019 年 9 月),我们通过添加扩展属性 ViewModel.viewModelScope 在 ViewModel 中加入了协程的支持。
将 Kotlin 协程与架构组件一起使用
https://developer.android.google.cn/topic/libraries/architecture/coroutines#lifecycle-aware
class MyViewModel(): ViewModel() {
fun userNeedsDocs() {
// 在 ViewModel 中启动新的协程
viewModelScope.launch {
fetchDocs()
}
}
}
当 viewModelScope 被清除 (当 onCleared() 回调被调用时) 之后,它将自动取消它所启动的所有协程。这是一个标准做法,如果一个用户在尚未获取到数据时就关闭了应用,这时让请求继续完成就纯粹是在浪费电量。
为了提高安全性,CoroutineScope 会进行自行传播。也就是说,如果某个协程启动了另一个新的协程,它们都会在同一个 scope 中终止运行。这意味着,即使当某个您所依赖的代码库从您创建的 viewModelScope 中启动某个协程,您也有方法将其取消。
注意: 协程被挂起时,系统会以抛出 CancellationException 的方式协作取消协程。捕获顶级异常 (如Throwable) 的异常处理程序将捕获此异常。如果您做异常处理时消费了这个异常,或从未进行 suspend 操作,那么协程将会徘徊于半取消 (semi-canceled) 状态下。
fun runForever() {
// 在 ViewModel 中启动新的协程
viewModelScope.launch {
// 当 ViewModel 被清除后,下列代码也会被取消
while(true) {
delay(1_000)
// 每过 1 秒做点什么
}
}
}
协作取消 https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html#cancellation-and-timeouts
任务追踪
suspend fun fetchTwoDocs() {
coroutineScope {
launch { fetchDoc(1) }
async { fetchDoc(2) }
}
}
coroutineScope
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html
supervisorScope
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/supervisor-scope.html
处理一堆任务
这个动画展示了 coroutineScope 是如何追踪一千个协程的。
以上的重点是,使用 coroutineScope 和 supervisorScope 可以从任何 suspend function 来安全地启动协程。即使是启动一个新的协程,也不会出现泄漏,因为在新的协程完成之前,调用方始终处于挂起状态。
更厉害的是,coroutineScope 将会创建一个子 scope,所以一旦父 scope 被取消,它会将取消的消息传递给所有新的协程。如果调用方是 viewModelScope,这一千个协程在用户离开界面后都会自动被取消掉,非常整洁高效。
协程失败时发出报错信号
在协程中,报错信号是通过抛出异常来发出的,就像我们平常写的函数一样。来自 suspend 函数的异常将通过 resume 重新抛给调用方来处理。跟常规函数一样,您不仅可以使用 try/catch 这样的方式来处理错误,还可以构建抽象来按照您喜欢的方式进行错误处理。
val unrelatedScope = MainScope()
// 丢失错误的例子
suspend fun lostError() {
// 未使用结构化并发的 async
unrelatedScope.async {
throw InAsyncNoOneCanHearYou("except")
}
}
注意: 上述代码声明了一个无关联协程作用域,它将不会按照结构化并发的方式启动新的协程。还记得我在一开始说的结构化并发是一系列编程语言特性和实践指南的集合,在 suspend 函数中引入无关联协程作用域违背了结构化并发规则。
suspend fun foundError() {
coroutineScope {
async {
throw StructuredConcurrencyWill("throw")
}
}
}
使用结构化并发
取消任务 —— 当某项任务不再需要时取消它; 追踪任务 —— 当任务正在执行时,追踪它; 发出错误信号 —— 当协程失败时,发出错误信号表明有错误发生。
作用域取消时,它内部所有的协程也会被取消; suspend 函数返回时,意味着它的所有任务都已完成; 协程报错时,它所在的作用域或调用方会收到报错通知。
下一步
本篇文章,我们探讨了如何在 Android 的 ViewModel 中启动协程,以及如何在代码中运用结构化并发,来让我们的代码更易于维护和理解。
在下一篇文章中,我们将探讨如何在实际编码过程中使用协程,感兴趣的读者请继续关注我们的更新。
推荐阅读